| 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 |