返回 DeepSeek-Reasonix
extension_surface_test.go
根目录 / internal / cli / extension_surface_test.go
1 package cli
2
3 import (
4 "context"
5 "strings"
6 "testing"
7
8 "github.com/charmbracelet/x/ansi"
9
10 "reasonix/internal/control"
11 "reasonix/internal/event"
12 "reasonix/internal/i18n"
13 )
14
15 // extensionStubCtrl stubs the SessionAPI surface the extension slash dispatch
16 // and completion read; every other method panics via the embedded nil
17 // interface, which keeps these tests focused on the extension paths.
18 type extensionStubCtrl struct {
19 control.SessionAPI
20 actions []control.ExtensionActionView
21 customSent string
22 customFound bool
23 invokeName string
24 invokeArgs map[string]string
25 invokeMsg string
26 invokeErr error
27 }
28
29 func (s *extensionStubCtrl) ExtensionActions() []control.ExtensionActionView { return s.actions }
30 func (s *extensionStubCtrl) CustomCommand(string) (string, bool) {
31 return s.customSent, s.customFound
32 }
33 func (s *extensionStubCtrl) RunSkill(string) (string, bool) { return "", false }
34 func (s *extensionStubCtrl) SendWithRaw(string, string) {}
35 func (s *extensionStubCtrl) InvokeExtensionAction(_ context.Context, name string, args map[string]string) (string, error) {
36 s.invokeName, s.invokeArgs = name, args
37 return s.invokeMsg, s.invokeErr
38 }
39
40 func floatPtr(v float64) *float64 { return &v }
41
42 func statusPayload(severity string) *event.ExtensionSurfacePayload {
43 return &event.ExtensionSurfacePayload{
44 PluginID: "alpha", SurfaceID: "s1", Kind: event.ExtensionSurfaceStatus,
45 Status: &event.ExtensionStatusView{Label: "building", Detail: "3 of 9", Severity: severity},
46 }
47 }
48
49 func TestExtensionStatusLineSeverity(t *testing.T) {
50 tests := []struct {
51 severity string
52 glyph string
53 }{
54 {"info", "·"},
55 {"", "·"},
56 {"warn", "!"},
57 {"error", "✗"},
58 }
59 for _, tt := range tests {
60 line := ansi.Strip(extensionStatusLine(statusPayload(tt.severity)))
61 want := tt.glyph + " [alpha] building: 3 of 9"
62 if !strings.Contains(line, want) {
63 t.Errorf("severity %q: line = %q, want %q", tt.severity, line, want)
64 }
65 }
66 if got := ansi.Strip(extensionStatusLine(nil)); got != "" {
67 t.Fatalf("nil payload = %q, want empty", got)
68 }
69 // Progress appends a percentage.
70 p := statusPayload("info")
71 p.Status.Progress = floatPtr(0.5)
72 if line := ansi.Strip(extensionStatusLine(p)); !strings.Contains(line, "(50%)") {
73 t.Fatalf("progress line = %q, want (50%%)", line)
74 }
75 }
76
77 func TestExtensionNotificationLine(t *testing.T) {
78 p := &event.ExtensionSurfacePayload{
79 PluginID: "alpha", SurfaceID: "n1", Kind: event.ExtensionSurfaceNotification,
80 Notification: &event.ExtensionNotificationView{Title: "Deploy done", Body: "v2 live", Severity: "warn"},
81 }
82 line := ansi.Strip(extensionNotificationLine(p))
83 if !strings.Contains(line, "! [alpha] Deploy done — v2 live") {
84 t.Fatalf("notification line = %q", line)
85 }
86 }
87
88 func TestExtensionCardLines(t *testing.T) {
89 card := &event.ExtensionCardView{
90 Title: "CI status",
91 Text: "all green",
92 Fields: []event.ExtensionKeyValue{{Key: "branch", Value: "main"}},
93 Progress: floatPtr(1),
94 Actions: []event.ExtensionActionRef{{ActionID: "rerun", Label: "Rerun"}},
95 }
96 lines := extensionCardLines("alpha", card, 80)
97 joined := ansi.Strip(strings.Join(lines, "\n"))
98 for _, want := range []string{"◆ CI status", "all green", "branch: main", "100%", "/alpha:rerun", "Rerun"} {
99 if !strings.Contains(joined, want) {
100 t.Errorf("card missing %q:\n%s", want, joined)
101 }
102 }
103
104 // Empty title falls back to the plugin id; markdown body renders through
105 // the same renderer as assistant answers (content passes through).
106 md := extensionCardLines("alpha", &event.ExtensionCardView{Markdown: "**bold** body"}, 80)
107 plain := ansi.Strip(strings.Join(md, "\n"))
108 if !strings.Contains(plain, "◆ alpha") || !strings.Contains(plain, "bold") {
109 t.Fatalf("markdown card = %q", plain)
110 }
111 }
112
113 func TestExtensionFormLines(t *testing.T) {
114 lines := extensionFormLines("alpha", &event.ExtensionFormView{Title: "Setup", Message: "pick options"})
115 joined := ansi.Strip(strings.Join(lines, "\n"))
116 if !strings.Contains(joined, "◆ Setup") || !strings.Contains(joined, "pick options") ||
117 !strings.Contains(joined, i18n.M.ExtFormFieldsHint) {
118 t.Fatalf("form card = %q", joined)
119 }
120 }
121
122 func TestExtensionSurfaceLinesDispatch(t *testing.T) {
123 if got := extensionSurfaceLines(nil, 80); got != nil {
124 t.Fatalf("nil payload = %v, want nil", got)
125 }
126 if got := extensionSurfaceLines(statusPayload("info"), 80); got != nil {
127 t.Fatalf("status payload is not a surface card: %v", got)
128 }
129 p := &event.ExtensionSurfacePayload{
130 PluginID: "alpha", Kind: event.ExtensionSurfaceCard,
131 Card: &event.ExtensionCardView{Title: "t"},
132 }
133 if got := extensionSurfaceLines(p, 80); len(got) == 0 {
134 t.Fatal("card payload produced no lines")
135 }
136 }
137
138 func TestParseExtensionActionArgs(t *testing.T) {
139 if got := parseExtensionActionArgs(nil); got != nil {
140 t.Fatalf("no fields = %v, want nil", got)
141 }
142 got := parseExtensionActionArgs([]string{"k=v", "extra", "empty="})
143 if got["k"] != "v" || got["arg1"] != "extra" || got["empty"] != "" {
144 t.Fatalf("args = %v", got)
145 }
146 }
147
148 func TestMatchExtensionAction(t *testing.T) {
149 ctrl := &extensionStubCtrl{actions: []control.ExtensionActionView{{
150 PluginID: "alpha", ActionID: "act1", Label: "Act one", Slash: "/alpha:act1",
151 }}}
152 action, ok := matchExtensionAction(ctrl, "/alpha:act1")
153 if !ok || action.Slash != "/alpha:act1" {
154 t.Fatalf("match = %+v, %v", action, ok)
155 }
156 if _, ok := matchExtensionAction(ctrl, "/alpha:other"); ok {
157 t.Fatal("undeclared action matched")
158 }
159 if _, ok := matchExtensionAction(ctrl, "/plain"); ok {
160 t.Fatal("non-action slash matched")
161 }
162 if _, ok := matchExtensionAction(nil, "/alpha:act1"); ok {
163 t.Fatal("nil controller matched")
164 }
165 }
166
167 func TestSlashCompletionIncludesExtensionActions(t *testing.T) {
168 m := newTestChatTUI()
169 m.ctrl = &extensionStubCtrl{actions: []control.ExtensionActionView{{
170 PluginID: "alpha", ActionID: "act1", Label: "Act one", Slash: "/alpha:act1",
171 }}}
172 m.input.SetValue("/alpha")
173 m.updateCompletion()
174
175 if !m.completion.active {
176 t.Fatal("slash menu did not open for /alpha")
177 }
178 var item *compItem
179 for i := range m.completion.items {
180 if m.completion.items[i].label == "/alpha:act1" {
181 item = &m.completion.items[i]
182 }
183 }
184 if item == nil {
185 t.Fatalf("extension action missing from completion: %v", labels(m.completion.items))
186 }
187 if item.insert != "/alpha:act1 " || !strings.Contains(item.hint, "plugin alpha") || !strings.Contains(item.hint, "Act one") {
188 t.Fatalf("completion item = %+v", item)
189 }
190 }
191
192 func TestRunSlashCommandInvokesExtensionAction(t *testing.T) {
193 m := newTestChatTUI()
194 ctrl := &extensionStubCtrl{
195 actions: []control.ExtensionActionView{{PluginID: "alpha", ActionID: "act1", Slash: "/alpha:act1"}},
196 invokeMsg: "rerun scheduled",
197 }
198 m.ctrl = ctrl
199
200 cmd := m.runSlashCommand("/alpha:act1 k=v extra")
201 if cmd == nil {
202 t.Fatal("extension action returned no cmd")
203 }
204 // The command line echoes synchronously; the invocation itself is async.
205 if plain := ansi.Strip(strings.Join(m.transcript, "\n")); !strings.Contains(plain, "› /alpha:act1 k=v extra") {
206 t.Fatalf("echo missing, transcript = %q", plain)
207 }
208 msg, ok := cmd().(extensionActionMsg)
209 if !ok {
210 t.Fatalf("cmd delivered %T, want extensionActionMsg", cmd())
211 }
212 if msg.err != nil || msg.message != "rerun scheduled" {
213 t.Fatalf("msg = %+v", msg)
214 }
215 if ctrl.invokeName != "/alpha:act1" || ctrl.invokeArgs["k"] != "v" || ctrl.invokeArgs["arg1"] != "extra" {
216 t.Fatalf("invoked %q with %v", ctrl.invokeName, ctrl.invokeArgs)
217 }
218 }
219
220 func TestRunSlashCommandResolutionOrder(t *testing.T) {
221 // A custom command of the same name wins; the extension action never fires.
222 m := newTestChatTUI()
223 ctrl := &extensionStubCtrl{
224 actions: []control.ExtensionActionView{{PluginID: "alpha", ActionID: "act1", Slash: "/alpha:act1"}},
225 customSent: "expanded",
226 customFound: true,
227 }
228 m.ctrl = ctrl
229 if cmd := m.runSlashCommand("/alpha:act1"); cmd == nil {
230 t.Fatal("custom command branch returned no cmd")
231 }
232 if ctrl.invokeName != "" {
233 t.Fatalf("extension action invoked despite custom command: %q", ctrl.invokeName)
234 }
235 if m.pendingRestore != "/alpha:act1" {
236 t.Fatalf("custom command should start a turn (bubble pending), pendingRestore = %q", m.pendingRestore)
237 }
238
239 // Nothing matches → the extension action never fires and the line falls
240 // through to the unknown-slash behavior: sent as a regular message with a
241 // visible notice (#5756).
242 m2 := newTestChatTUI()
243 m2.ctrl = &extensionStubCtrl{}
244 if cmd := m2.runSlashCommand("/alpha:act1"); cmd == nil {
245 t.Fatal("unknown slash should start a regular-message turn")
246 }
247 if plain := ansi.Strip(strings.Join(m2.transcript, "\n")); !strings.Contains(plain, "unknown command: /alpha:act1") {
248 t.Fatalf("unknown notice missing, transcript = %q", plain)
249 }
250 if m2.pendingRestore != "/alpha:act1" {
251 t.Fatalf("unknown slash should be sent as a regular message, pendingRestore = %q", m2.pendingRestore)
252 }
253 }
254
255 func TestIngestExtensionEvents(t *testing.T) {
256 m := newTestChatTUI()
257 m.ingestEvent(event.Event{Kind: event.ExtensionStatus, Extension: statusPayload("warn")})
258 m.ingestEvent(event.Event{Kind: event.ExtensionSurface, Extension: &event.ExtensionSurfacePayload{
259 PluginID: "alpha", Kind: event.ExtensionSurfaceCard,
260 Card: &event.ExtensionCardView{Title: "CI", Text: "green"},
261 }})
262 m.ingestEvent(event.Event{Kind: event.ExtensionSurface, Extension: &event.ExtensionSurfacePayload{
263 PluginID: "alpha", Kind: event.ExtensionSurfaceNotification,
264 Notification: &event.ExtensionNotificationView{Title: "heads up", Severity: "error"},
265 }})
266
267 plain := ansi.Strip(strings.Join(m.transcript, "\n"))
268 for _, want := range []string{"! [alpha] building: 3 of 9", "◆ CI", "green", "✗ [alpha] heads up"} {
269 if !strings.Contains(plain, want) {
270 t.Errorf("transcript missing %q:\n%s", want, plain)
271 }
272 }
273
274 // A nil payload stays silent instead of panicking.
275 m2 := newTestChatTUI()
276 m2.ingestEvent(event.Event{Kind: event.ExtensionSurface})
277 if len(m2.transcript) != 0 {
278 t.Fatalf("nil extension payload committed lines: %v", m2.transcript)
279 }
280 }
281
281 lines GO