okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: /extensions on listView: filter, details beside the list, enter for global and shift+enter for this session647d18b

main
8.0 KiB164 linesraw
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) {
19	m := NewExtensionPickerModel(nil, "s")
20	m.SetSize(120, 30)
21	m, _ = 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	}})
25	view := ansi.Strip(m.View())
26	if !strings.Contains(view, "off  view") || !strings.Contains(view, "this session: on") || !strings.Contains(view, "follows global") && !strings.Contains(view, "its own choice") {
27		t.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.
32	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
33	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "Enable view globally?") {
34		t.Fatalf("enter should ask about the global default:\n%s", ansi.Strip(m.View()))
35	}
36	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
37	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
38	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "Disable view for this session only?") {
39		t.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.
42	m, _ = m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"})
43	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
44	if !m.Confirming() || m.Cursor != 0 || m.Saving {
45		t.Fatal("a stray key must neither answer nor move past the question")
46	}
47	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
48
49	// ctrl+x drops this session's choice, and only when it has one.
50	m, _ = m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
51	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "follow the global default") {
52		t.Fatalf("ctrl+x should confirm dropping the session choice:\n%s", ansi.Strip(m.View()))
53	}
54	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
55	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
56	m, _ = m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
57	if m.Confirming() {
58		t.Fatal("ctrl+x needs a session choice to drop")
59	}
60}
61
62func TestExtensionsFilterByTypingAndShowDetailsWithoutEnter(t *testing.T) {
63	m := NewExtensionPickerModel(nil, "s")
64	m.SetSize(120, 30)
65	m, _ = 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	}})
69	for _, r := range "bsh" {
70		m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
71	}
72	view := ansi.Strip(m.View())
73	if m.Confirming() || strings.Contains(view, "highlighted images") && !strings.Contains(view, "bash") || !strings.Contains(view, "run_command") {
74		t.Fatalf("typing should filter to bash and its details should show beside the list:\n%s", view)
75	}
76	if ext, _ := m.current(); ext.Name != "bash" {
77		t.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) {
84	for _, screen := range []string{"picker", "capability"} {
85		for _, refusal := range []string{"outcome", "http"} {
86			t.Run(screen+"/"+refusal, func(t *testing.T) {
87				saves, reloads := 0, 0
88				failureNotice := "reload cannot apply"
89				if refusal == "http" {
90					failureNotice = "reload not confirmed:"
91				}
92				session := protocolSession("s", generationA, 0)
93				resource := session["configuration_resource"].(map[string]any)
94				resource["etag"] = "\"saved\""
95				candidate := protocolCandidate("skills", "Skills", true)
96				candidate["kind"] = "extension"
97				conn := commandTestConnection(t, func(w http.ResponseWriter, request *http.Request) {
98					var body any
99					switch {
100					case request.Method == http.MethodPatch:
101						saves++
102						if request.URL.RequestURI() != "/sessions/s?view=configuration" || request.Header.Get("If-Match") != "\"observed\"" {
103							t.Fatalf("wrong desired write %s, validator %q", request.URL, request.Header.Get("If-Match"))
104						}
105						body = map[string]any{"resource": resource, "session": session, "move": nil}
106					case request.Method == http.MethodPost:
107						reloads++
108						var reload daemon.ReloadRequest
109						if request.URL.Path != "/sessions/s/reload" || json.NewDecoder(request.Body).Decode(&reload) != nil || reload.Target != "session" {
110							t.Fatal("enable did not issue a typed session reload")
111						}
112						if refusal == "http" {
113							w.WriteHeader(http.StatusServiceUnavailable)
114							body = map[string]any{"type": "about:blank", "title": "Reload unavailable", "status": 503, "code": "reload_unavailable", "detail": "reload cannot apply"}
115						} else {
116							body = 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						}
118					case request.URL.Path == "/sessions/s/catalog":
119						body = 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}}
120					case request.URL.Path == "/sessions/s":
121						w.Header().Set("ETag", "\"saved\"")
122						body = resource["value"]
123					case request.URL.Path == "/settings":
124						body = testwire.Settings()
125					default:
126						t.Fatalf("unexpected request %s %s", request.Method, request.URL)
127					}
128					_ = json.NewEncoder(w).Encode(body)
129				})
130				if screen == "picker" {
131					model := NewExtensionPickerModel(conn, "s")
132					model.Loading, model.Saving = false, true
133					model.confirm.ask(scopeSession, "skills", "enable", "Enable skills?")
134					model.Extensions = []ExtensionItem{{Name: "skills", SessionETag: "\"observed\""}}
135					message := model.changeExtensionCmd("skills", "session", true, model.Generation)()
136					model, _ = model.Update(message)
137					if 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) {
138						t.Fatalf("confirmed save was lost after reload failed: %+v", model)
139					}
140				} else {
141					model := NewCapabilityPageModel(conn, "s", "skills")
142					model.Loading, model.Saving, model.SessionETag = false, true, "\"observed\""
143					message := model.enableExtensionCmd(model.Generation)()
144					model, command := model.Update(message)
145					if model.Saving || !model.Loading || model.Error != "" || command == nil || !strings.Contains(model.Notice, "Extension enabled;") || !strings.Contains(model.Notice, failureNotice) {
146						t.Fatalf("extension save was lost after reload failed: %+v", model)
147					}
148					for _, next := range command().(tea.BatchMsg) {
149						message := next()
150						if _, loaded := message.(capabilityLoadedMsg); loaded {
151							model, _ = model.Update(message)
152						}
153					}
154					if model.Loading || !model.ExtensionEnabled || model.SessionETag != "\"saved\"" {
155						t.Fatal("failed reload left stale desired selection or validator")
156					}
157				}
158				if saves != 1 || reloads != 1 {
159					t.Fatalf("save/reload were replayed: %d/%d", saves, reloads)
160				}
161			})
162		}
163	}
164}