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