返回 DeepSeek-Reasonix
hub_test.go
根目录 / internal / extension / uihub / hub_test.go
1 package uihub
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "sync"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/extension/protocol"
13 )
14
15 const testCredential = "api_key=sk-abcdef1234567890SECRETKEY"
16
17 // eventRecorder collects emitted events, safe for concurrent hub traffic.
18 type eventRecorder struct {
19 mu sync.Mutex
20 events []event.Event
21 }
22
23 func (r *eventRecorder) emit(ev event.Event) {
24 r.mu.Lock()
25 defer r.mu.Unlock()
26 r.events = append(r.events, ev)
27 }
28
29 func (r *eventRecorder) all() []event.Event {
30 r.mu.Lock()
31 defer r.mu.Unlock()
32 return append([]event.Event(nil), r.events...)
33 }
34
35 func newTestHub(rec *eventRecorder) *Hub {
36 return New(Options{
37 SessionID: "sess-1",
38 Generation: 7,
39 Emit: rec.emit,
40 Warn: func(string) {},
41 })
42 }
43
44 func publishRaw(t *testing.T, h *Hub, pluginID string, p protocol.UIPublishParams) protocol.UIPublishResult {
45 t.Helper()
46 result, err := h.HandlerFor(pluginID).Publish(context.Background(), p)
47 if err != nil {
48 t.Fatalf("Publish: %v", err)
49 }
50 return result
51 }
52
53 func mustRaw(t *testing.T, v any) json.RawMessage {
54 t.Helper()
55 raw, err := json.Marshal(v)
56 if err != nil {
57 t.Fatalf("marshal: %v", err)
58 }
59 return raw
60 }
61
62 func TestPublishStatusEmitsRedactedStatusEvent(t *testing.T) {
63 rec := &eventRecorder{}
64 h := newTestHub(rec)
65 progress := 0.5
66 result := publishRaw(t, h, "alpha", protocol.UIPublishParams{
67 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
68 Payload: mustRaw(t, protocol.UIStatusPayload{
69 Label: "working " + testCredential, Detail: "detail " + testCredential,
70 Severity: protocol.UISeverityWarn, Progress: &progress,
71 }),
72 })
73 if !result.Accepted {
74 t.Fatal("status publish not accepted")
75 }
76 events := rec.all()
77 if len(events) != 1 {
78 t.Fatalf("emitted %d events, want 1", len(events))
79 }
80 ev := events[0]
81 if ev.Kind != event.ExtensionStatus {
82 t.Fatalf("event kind = %v, want ExtensionStatus", ev.Kind)
83 }
84 payload := ev.Extension
85 if payload == nil || payload.Status == nil {
86 t.Fatalf("extension payload = %+v", payload)
87 }
88 if payload.PluginID != "alpha" || payload.SurfaceID != "s1" || payload.SessionID != "sess-1" || payload.Generation != 7 {
89 t.Fatalf("payload identity = %+v", payload)
90 }
91 if payload.Kind != event.ExtensionSurfaceStatus {
92 t.Fatalf("payload kind = %q", payload.Kind)
93 }
94 if payload.Status.Severity != "warn" || payload.Status.Progress == nil || *payload.Status.Progress != 0.5 {
95 t.Fatalf("status = %+v", payload.Status)
96 }
97 for _, s := range []string{payload.Status.Label, payload.Status.Detail} {
98 if strings.Contains(s, "sk-abcdef") || !strings.Contains(s, "****") {
99 t.Fatalf("status text not redacted: %q", s)
100 }
101 }
102 }
103
104 func TestPublishCardFormNotificationEmitSurfaceEvents(t *testing.T) {
105 rec := &eventRecorder{}
106 h := newTestHub(rec)
107 handler := h.HandlerFor("alpha")
108
109 cardProgress := 1.0
110 card := protocol.UICardPayload{
111 Title: "T " + testCredential, Markdown: "**m** " + testCredential, Text: "x",
112 Fields: []protocol.UIKeyValue{{Key: "k", Value: "v " + testCredential}},
113 Progress: &cardProgress,
114 Actions: []protocol.UIActionRef{{ActionID: "act1", Label: "go " + testCredential}},
115 }
116 if result, err := handler.Publish(context.Background(), protocol.UIPublishParams{
117 SurfaceID: "c1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceCard,
118 Payload: mustRaw(t, card),
119 }); err != nil || !result.Accepted {
120 t.Fatalf("card publish = %+v, %v", result, err)
121 }
122
123 form := protocol.UIFormPayload{
124 Title: "f", Message: "m " + testCredential,
125 Fields: []protocol.UIFormField{{
126 Key: "field1", Label: "L " + testCredential, Kind: protocol.UIFieldSelect,
127 Options: []string{"a " + testCredential, "b"}, Default: "d " + testCredential, Required: true,
128 }},
129 }
130 if result, err := handler.Publish(context.Background(), protocol.UIPublishParams{
131 SurfaceID: "f1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceForm,
132 Payload: mustRaw(t, form),
133 }); err != nil || !result.Accepted {
134 t.Fatalf("form publish = %+v, %v", result, err)
135 }
136
137 notification := protocol.UINotificationPayload{Title: "n " + testCredential, Body: "b " + testCredential, Severity: protocol.UISeverityError}
138 if result, err := handler.Publish(context.Background(), protocol.UIPublishParams{
139 SurfaceID: "n1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceNotification,
140 Payload: mustRaw(t, notification),
141 }); err != nil || !result.Accepted {
142 t.Fatalf("notification publish = %+v, %v", result, err)
143 }
144
145 events := rec.all()
146 if len(events) != 3 {
147 t.Fatalf("emitted %d events, want 3", len(events))
148 }
149 for _, ev := range events {
150 if ev.Kind != event.ExtensionSurface {
151 t.Fatalf("event kind = %v, want ExtensionSurface", ev.Kind)
152 }
153 }
154
155 gotCard := events[0].Extension.Card
156 if gotCard == nil || gotCard.Title == "" || len(gotCard.Fields) != 1 || len(gotCard.Actions) != 1 {
157 t.Fatalf("card view = %+v", gotCard)
158 }
159 if gotCard.Actions[0].ActionID != "act1" {
160 t.Fatalf("card action id = %q", gotCard.Actions[0].ActionID)
161 }
162 for _, s := range []string{gotCard.Title, gotCard.Markdown, gotCard.Fields[0].Value, gotCard.Actions[0].Label} {
163 if strings.Contains(s, "sk-abcdef") {
164 t.Fatalf("card text not redacted: %q", s)
165 }
166 }
167
168 gotForm := events[1].Extension.Form
169 if gotForm == nil || len(gotForm.Fields) != 1 {
170 t.Fatalf("form view = %+v", gotForm)
171 }
172 field := gotForm.Fields[0]
173 if field.Key != "field1" || field.Kind != "select" || !field.Required || len(field.Options) != 2 {
174 t.Fatalf("form field = %+v", field)
175 }
176 if strings.Contains(gotForm.Message, "sk-abcdef") || strings.Contains(field.Label, "sk-abcdef") ||
177 strings.Contains(field.Options[0], "sk-abcdef") || strings.Contains(field.Default.(string), "sk-abcdef") {
178 t.Fatalf("form text not redacted: %+v", gotForm)
179 }
180
181 gotNotification := events[2].Extension.Notification
182 if gotNotification == nil || gotNotification.Severity != "error" {
183 t.Fatalf("notification view = %+v", gotNotification)
184 }
185 if strings.Contains(gotNotification.Title, "sk-abcdef") || strings.Contains(gotNotification.Body, "sk-abcdef") {
186 t.Fatalf("notification text not redacted: %+v", gotNotification)
187 }
188 }
189
190 func TestPublishStaleGenerationDropped(t *testing.T) {
191 rec := &eventRecorder{}
192 h := newTestHub(rec)
193 result := publishRaw(t, h, "alpha", protocol.UIPublishParams{
194 SurfaceID: "s1", SessionID: "sess-1", Generation: 6, Kind: protocol.UISurfaceStatus,
195 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "old"}),
196 })
197 if result.Accepted {
198 t.Fatal("stale-generation publish accepted")
199 }
200 if len(rec.all()) != 0 {
201 t.Fatalf("stale publish emitted events: %+v", rec.all())
202 }
203 }
204
205 func TestPublishWrongSessionDropped(t *testing.T) {
206 rec := &eventRecorder{}
207 h := newTestHub(rec)
208 result := publishRaw(t, h, "alpha", protocol.UIPublishParams{
209 SurfaceID: "s1", SessionID: "sess-other", Generation: 7, Kind: protocol.UISurfaceStatus,
210 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "wrong session"}),
211 })
212 if result.Accepted {
213 t.Fatal("wrong-session publish accepted")
214 }
215 if len(rec.all()) != 0 {
216 t.Fatalf("wrong-session publish emitted events: %+v", rec.all())
217 }
218 }
219
220 func TestPublishMalformedPayloadProtocolError(t *testing.T) {
221 rec := &eventRecorder{}
222 h := newTestHub(rec)
223 _, err := h.HandlerFor("alpha").Publish(context.Background(), protocol.UIPublishParams{
224 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
225 Payload: json.RawMessage(`{"label":"x","bogus":true}`),
226 })
227 var protocolErr *protocol.ProtocolError
228 if !errors.As(err, &protocolErr) || protocolErr.Reason != protocol.ErrInvalidParams {
229 t.Fatalf("malformed payload error = %v, want invalid_params ProtocolError", err)
230 }
231 }
232
233 func TestPublishUnknownClientRejected(t *testing.T) {
234 rec := &eventRecorder{}
235 h := newTestHub(rec)
236 // The bare hub handler has no binding; neither does a fabricated binding
237 // for a plugin the manager never announced.
238 for _, handler := range []UIHandler{h, binding{pluginID: "ghost", hub: h}} {
239 _, err := handler.Publish(context.Background(), protocol.UIPublishParams{
240 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
241 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "x"}),
242 })
243 var protocolErr *protocol.ProtocolError
244 if !errors.As(err, &protocolErr) {
245 t.Fatalf("unknown client publish error = %v, want ProtocolError", err)
246 }
247 }
248 }
249
250 func TestPublishCrashedClientRejected(t *testing.T) {
251 rec := &eventRecorder{}
252 h := newTestHub(rec)
253 handler := h.HandlerFor("alpha")
254 h.ClientCrashed("alpha")
255 _, err := handler.Publish(context.Background(), protocol.UIPublishParams{
256 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
257 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "x"}),
258 })
259 var protocolErr *protocol.ProtocolError
260 if !errors.As(err, &protocolErr) || protocolErr.Reason != protocol.ErrProviderInterrupted {
261 t.Fatalf("crashed publish error = %v, want provider_interrupted", err)
262 }
263 // A fresh binding (replacement sidecar) is live again.
264 if result := publishRaw(t, h, "alpha", protocol.UIPublishParams{
265 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
266 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "revived"}),
267 }); !result.Accepted {
268 t.Fatal("publish after re-binding not accepted")
269 }
270 }
271
272 func TestRequestKindsTranslateToAskChannel(t *testing.T) {
273 tests := []struct {
274 name string
275 kind protocol.UIRequestKind
276 form protocol.UIFormPayload
277 answers []event.AskAnswer
278 wantVals map[string]any
279 checkQ func(t *testing.T, q []event.AskQuestion)
280 }{
281 {
282 name: "confirm",
283 kind: protocol.UIRequestConfirm,
284 form: protocol.UIFormPayload{Message: "proceed?", Fields: []protocol.UIFormField{}},
285 answers: []event.AskAnswer{
286 {QuestionID: "value", Selected: []string{"Yes"}},
287 },
288 wantVals: map[string]any{"value": true},
289 checkQ: func(t *testing.T, qs []event.AskQuestion) {
290 t.Helper()
291 if len(qs) != 1 || len(qs[0].Options) != 2 || qs[0].Options[0].Label != "Yes" {
292 t.Fatalf("confirm question = %+v", qs)
293 }
294 },
295 },
296 {
297 name: "input",
298 kind: protocol.UIRequestInput,
299 form: protocol.UIFormPayload{Title: "T", Fields: []protocol.UIFormField{
300 {Key: "name", Label: "Your name", Kind: protocol.UIFieldInput},
301 }},
302 answers: []event.AskAnswer{
303 {QuestionID: "name", Selected: []string{"free text answer"}},
304 },
305 wantVals: map[string]any{"name": "free text answer"},
306 checkQ: func(t *testing.T, qs []event.AskQuestion) {
307 t.Helper()
308 if len(qs) != 1 || len(qs[0].Options) != 0 || qs[0].Multi {
309 t.Fatalf("input question = %+v", qs)
310 }
311 },
312 },
313 {
314 name: "select",
315 kind: protocol.UIRequestSelect,
316 form: protocol.UIFormPayload{Fields: []protocol.UIFormField{
317 {Key: "color", Label: "Pick", Kind: protocol.UIFieldSelect, Options: []string{"red", "blue"}},
318 }},
319 answers: []event.AskAnswer{
320 {QuestionID: "color", Selected: []string{"blue"}},
321 },
322 wantVals: map[string]any{"color": "blue"},
323 checkQ: func(t *testing.T, qs []event.AskQuestion) {
324 t.Helper()
325 if len(qs) != 1 || len(qs[0].Options) != 2 || qs[0].Multi {
326 t.Fatalf("select question = %+v", qs)
327 }
328 },
329 },
330 {
331 name: "multiselect",
332 kind: protocol.UIRequestMultiselect,
333 form: protocol.UIFormPayload{Fields: []protocol.UIFormField{
334 {Key: "tags", Label: "Tags", Kind: protocol.UIFieldMultiselect, Options: []string{"a", "b", "c"}},
335 }},
336 answers: []event.AskAnswer{
337 {QuestionID: "tags", Selected: []string{"a", "c"}},
338 },
339 wantVals: map[string]any{"tags": []string{"a", "c"}},
340 checkQ: func(t *testing.T, qs []event.AskQuestion) {
341 t.Helper()
342 if len(qs) != 1 || !qs[0].Multi || len(qs[0].Options) != 3 {
343 t.Fatalf("multiselect question = %+v", qs)
344 }
345 },
346 },
347 }
348 for _, tt := range tests {
349 t.Run(tt.name, func(t *testing.T) {
350 var gotQuestions []event.AskQuestion
351 rec := &eventRecorder{}
352 h := New(Options{
353 SessionID: "sess-1", Generation: 7, Emit: rec.emit,
354 Request: AskRequestFunc(func(_ context.Context, qs []event.AskQuestion) ([]event.AskAnswer, error) {
355 gotQuestions = append([]event.AskQuestion(nil), qs...)
356 return tt.answers, nil
357 }),
358 })
359 result, err := h.HandlerFor("alpha").Request(context.Background(), protocol.UIRequestParams{
360 SurfaceID: "r1", SessionID: "sess-1", Generation: 7, Kind: tt.kind,
361 Payload: mustRaw(t, tt.form),
362 })
363 if err != nil {
364 t.Fatalf("Request: %v", err)
365 }
366 if result.Cancelled {
367 t.Fatal("request reported cancelled")
368 }
369 if len(result.Values) != len(tt.wantVals) {
370 t.Fatalf("values = %+v, want %+v", result.Values, tt.wantVals)
371 }
372 for key, want := range tt.wantVals {
373 got := result.Values[key]
374 switch wantVal := want.(type) {
375 case []string:
376 gotSlice, ok := got.([]string)
377 if !ok || len(gotSlice) != len(wantVal) {
378 t.Fatalf("values[%q] = %#v, want %#v", key, got, want)
379 }
380 for i := range wantVal {
381 if gotSlice[i] != wantVal[i] {
382 t.Fatalf("values[%q] = %#v, want %#v", key, got, want)
383 }
384 }
385 default:
386 if got != want {
387 t.Fatalf("values[%q] = %#v, want %#v", key, got, want)
388 }
389 }
390 }
391 tt.checkQ(t, gotQuestions)
392 })
393 }
394 }
395
396 func TestRequestCancelledWhenDismissed(t *testing.T) {
397 h := New(Options{
398 SessionID: "sess-1", Generation: 7,
399 Request: AskRequestFunc(func(context.Context, []event.AskQuestion) ([]event.AskAnswer, error) {
400 return nil, nil // the controller's skip path: no selections at all
401 }),
402 })
403 result, err := h.HandlerFor("alpha").Request(context.Background(), protocol.UIRequestParams{
404 SurfaceID: "r1", SessionID: "sess-1", Generation: 7, Kind: protocol.UIRequestConfirm,
405 Payload: mustRaw(t, protocol.UIFormPayload{Message: "proceed?", Fields: []protocol.UIFormField{}}),
406 })
407 if err != nil {
408 t.Fatalf("Request: %v", err)
409 }
410 if !result.Cancelled {
411 t.Fatalf("dismissed request = %+v, want cancelled", result)
412 }
413 }
414
415 func TestRequestRedactsPromptText(t *testing.T) {
416 var gotReq HubRequest
417 h := New(Options{
418 SessionID: "sess-1", Generation: 7,
419 Request: func(_ context.Context, req HubRequest) (map[string]any, bool, error) {
420 gotReq = req
421 return map[string]any{"field1": "x"}, false, nil
422 },
423 })
424 _, err := h.HandlerFor("alpha").Request(context.Background(), protocol.UIRequestParams{
425 SurfaceID: "r1", SessionID: "sess-1", Generation: 7, Kind: protocol.UIRequestSelect,
426 Payload: mustRaw(t, protocol.UIFormPayload{
427 Title: "t " + testCredential, Message: "m " + testCredential,
428 Fields: []protocol.UIFormField{{Key: "field1", Label: "L " + testCredential, Kind: protocol.UIFieldSelect, Options: []string{"o " + testCredential}}},
429 }),
430 })
431 if err != nil {
432 t.Fatalf("Request: %v", err)
433 }
434 for _, s := range []string{gotReq.Title, gotReq.Message, gotReq.Fields[0].Label, gotReq.Fields[0].Options[0]} {
435 if strings.Contains(s, "sk-abcdef") {
436 t.Fatalf("request prompt text not redacted: %q", s)
437 }
438 }
439 }
440
441 func TestRequestStaleGenerationAnsweredCancelled(t *testing.T) {
442 called := false
443 h := New(Options{
444 SessionID: "sess-1", Generation: 7,
445 Request: func(context.Context, HubRequest) (map[string]any, bool, error) {
446 called = true
447 return nil, false, nil
448 },
449 })
450 result, err := h.HandlerFor("alpha").Request(context.Background(), protocol.UIRequestParams{
451 SurfaceID: "r1", SessionID: "sess-1", Generation: 6, Kind: protocol.UIRequestConfirm,
452 Payload: mustRaw(t, protocol.UIFormPayload{Message: "proceed?", Fields: []protocol.UIFormField{}}),
453 })
454 if err != nil {
455 t.Fatalf("Request: %v", err)
456 }
457 if !result.Cancelled {
458 t.Fatalf("stale request = %+v, want cancelled", result)
459 }
460 if called {
461 t.Fatal("stale request reached the Ask channel")
462 }
463 }
464
465 // TestStageHoldsOldGenerationUntilCommit documents the narrow-rebuild UI policy:
466 // stage reuses the previous UI hub and does not call BindGeneration. A sidecar
467 // that emits host/ui/publish or host/ui/request with the staged (next)
468 // generation during handshake/ready is dropped as stale. Only commit binds the
469 // new generation; plugins must not rely on UI visibility before then.
470 func TestStageHoldsOldGenerationUntilCommit(t *testing.T) {
471 rec := &eventRecorder{}
472 h := newTestHub(rec) // bound to sess-1 / generation 7
473 handler := h.HandlerFor("alpha")
474
475 // Live generation remains valid for the whole stage window.
476 if r := publishRaw(t, h, "alpha", protocol.UIPublishParams{
477 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
478 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "live"}),
479 }); !r.Accepted {
480 t.Fatal("current generation must still publish during stage")
481 }
482
483 // Staged next generation is not bound yet — silent drop.
484 if r := publishRaw(t, h, "alpha", protocol.UIPublishParams{
485 SurfaceID: "s1", SessionID: "sess-1", Generation: 8, Kind: protocol.UISurfaceStatus,
486 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "premature"}),
487 }); r.Accepted {
488 t.Fatal("staged next-generation publish must be dropped before BindGeneration")
489 }
490
491 req, err := handler.Request(context.Background(), protocol.UIRequestParams{
492 SurfaceID: "r1", SessionID: "sess-1", Generation: 8, Kind: protocol.UIRequestConfirm,
493 Payload: mustRaw(t, protocol.UIFormPayload{Message: "proceed?", Fields: []protocol.UIFormField{}}),
494 })
495 if err != nil {
496 t.Fatalf("Request: %v", err)
497 }
498 if !req.Cancelled {
499 t.Fatalf("staged next-generation request = %+v, want cancelled", req)
500 }
501
502 // Commit binds the new generation (see boot.commitControllerExtPatch).
503 h.BindGeneration("sess-1", 8)
504 if r := publishRaw(t, h, "alpha", protocol.UIPublishParams{
505 SurfaceID: "s1", SessionID: "sess-1", Generation: 8, Kind: protocol.UISurfaceStatus,
506 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "committed"}),
507 }); !r.Accepted {
508 t.Fatal("post-commit generation must publish")
509 }
510 if r := publishRaw(t, h, "alpha", protocol.UIPublishParams{
511 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
512 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "old"}),
513 }); r.Accepted {
514 t.Fatal("pre-commit generation must drop after BindGeneration")
515 }
516
517 events := rec.all()
518 if len(events) != 2 {
519 t.Fatalf("emitted %d events, want 2 (live + committed; premature/old dropped)", len(events))
520 }
521 if events[0].Extension == nil || events[0].Extension.Status == nil || events[0].Extension.Status.Label != "live" {
522 t.Fatalf("first event = %+v, want live", events[0].Extension)
523 }
524 if events[1].Extension == nil || events[1].Extension.Status == nil || events[1].Extension.Status.Label != "committed" {
525 t.Fatalf("second event = %+v, want committed", events[1].Extension)
526 }
527 }
528
529 func TestRebindDropsOldGeneration(t *testing.T) {
530 rec := &eventRecorder{}
531 h := newTestHub(rec)
532 handler := h.HandlerFor("alpha")
533 // The reload re-binds the hub; the old generation's late publications must
534 // never overwrite the new state.
535 h.BindGeneration("sess-2", 8)
536 stale := func() protocol.UIPublishResult {
537 result, err := handler.Publish(context.Background(), protocol.UIPublishParams{
538 SurfaceID: "s1", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
539 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "old"}),
540 })
541 if err != nil {
542 t.Fatalf("Publish: %v", err)
543 }
544 return result
545 }
546 if result := stale(); result.Accepted {
547 t.Fatal("old-generation publish accepted after rebind")
548 }
549 result, err := handler.Publish(context.Background(), protocol.UIPublishParams{
550 SurfaceID: "s1", SessionID: "sess-2", Generation: 8, Kind: protocol.UISurfaceStatus,
551 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "new"}),
552 })
553 if err != nil || !result.Accepted {
554 t.Fatalf("new-generation publish = %+v, %v", result, err)
555 }
556 if len(rec.all()) != 1 {
557 t.Fatalf("emitted %d events, want exactly the new one", len(rec.all()))
558 }
559 }
560
561 // fakeActionClient records UIAction/UISubmit calls for the action tests.
562 type fakeActionClient struct {
563 mu sync.Mutex
564 actionParams []protocol.UIActionParams
565 actionResult protocol.UIActionResult
566 actionErr error
567 submitParams []protocol.UISubmitParams
568 submitResult protocol.UISubmitResult
569 submitErr error
570 submitStarted chan struct{}
571 submitRelease <-chan struct{}
572 }
573
574 func (f *fakeActionClient) UIAction(_ context.Context, p protocol.UIActionParams) (protocol.UIActionResult, error) {
575 f.mu.Lock()
576 defer f.mu.Unlock()
577 f.actionParams = append(f.actionParams, p)
578 return f.actionResult, f.actionErr
579 }
580
581 func (f *fakeActionClient) UISubmit(_ context.Context, p protocol.UISubmitParams) (protocol.UISubmitResult, error) {
582 f.mu.Lock()
583 f.submitParams = append(f.submitParams, p)
584 started, release := f.submitStarted, f.submitRelease
585 result, err := f.submitResult, f.submitErr
586 f.mu.Unlock()
587 if started != nil {
588 select {
589 case started <- struct{}{}:
590 default:
591 }
592 }
593 if release != nil {
594 <-release
595 }
596 return result, err
597 }
598
599 func TestRegisterActionsRejectsInvalidIDs(t *testing.T) {
600 h := newTestHub(&eventRecorder{})
601 for _, id := range []string{"", "Upper", "has space", "under_score", "slash/"} {
602 if err := h.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: id}}); err == nil {
603 t.Fatalf("RegisterActions accepted invalid id %q", id)
604 }
605 }
606 if err := h.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: "ok-action1"}}); err != nil {
607 t.Fatalf("RegisterActions rejected a valid id: %v", err)
608 }
609 }
610
611 func TestActionsEnumerateWithSlashNames(t *testing.T) {
612 h := newTestHub(&eventRecorder{})
613 if err := h.RegisterActions("beta", []protocol.UIActionDecl{{ActionID: "zap", Label: "Zap " + testCredential}}); err != nil {
614 t.Fatal(err)
615 }
616 if err := h.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: "act1", Label: "Act"}}); err != nil {
617 t.Fatal(err)
618 }
619 actions := h.Actions()
620 if len(actions) != 2 {
621 t.Fatalf("Actions = %+v", actions)
622 }
623 // Sorted by slash name: /alpha:act1 before /beta:zap.
624 if actions[0].Slash != "/alpha:act1" || actions[1].Slash != "/beta:zap" {
625 t.Fatalf("slash names = %+v", actions)
626 }
627 if actions[1].Label != "" && strings.Contains(actions[1].Label, "sk-abcdef") {
628 t.Fatalf("action label not redacted: %q", actions[1].Label)
629 }
630 }
631
632 func TestSlashNameRoundTrip(t *testing.T) {
633 if got := SlashName("alpha", "act1"); got != "/alpha:act1" {
634 t.Fatalf("SlashName = %q", got)
635 }
636 plugin, action, ok := ParseSlashName("/alpha:act1")
637 if !ok || plugin != "alpha" || action != "act1" {
638 t.Fatalf("ParseSlashName = %q, %q, %v", plugin, action, ok)
639 }
640 for _, bad := range []string{"alpha:act1", "/alpha", "/:act1", "/alpha:Bad Id", ""} {
641 if _, _, ok := ParseSlashName(bad); ok {
642 t.Fatalf("ParseSlashName accepted %q", bad)
643 }
644 }
645 }
646
647 func TestInvokeActionRoutesToOwningClient(t *testing.T) {
648 fake := &fakeActionClient{actionResult: protocol.UIActionResult{Accepted: true, Message: "done " + testCredential}}
649 h := New(Options{
650 SessionID: "sess-1", Generation: 7,
651 Resolve: func(pluginID string) ActionClient {
652 if pluginID == "alpha" {
653 return fake
654 }
655 return nil
656 },
657 })
658 if err := h.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: "act1"}}); err != nil {
659 t.Fatal(err)
660 }
661 result, err := h.InvokeAction(context.Background(), "alpha", "act1", "sess-1", map[string]string{"k": "v"})
662 if err != nil {
663 t.Fatalf("InvokeAction: %v", err)
664 }
665 if !result.Accepted {
666 t.Fatal("action not accepted")
667 }
668 if strings.Contains(result.Message, "sk-abcdef") {
669 t.Fatalf("result message not redacted: %q", result.Message)
670 }
671 if len(fake.actionParams) != 1 {
672 t.Fatalf("client action calls = %+v", fake.actionParams)
673 }
674 call := fake.actionParams[0]
675 if call.ActionID != "act1" || call.SessionID != "sess-1" || call.Generation != 7 || call.Args["k"] != "v" {
676 t.Fatalf("action params = %+v", call)
677 }
678 }
679
680 func TestInvokeActionRejectsUndeclaredUnknownAndStale(t *testing.T) {
681 fake := &fakeActionClient{actionResult: protocol.UIActionResult{Accepted: true}}
682 h := New(Options{
683 SessionID: "sess-1", Generation: 7,
684 Resolve: func(string) ActionClient { return fake },
685 })
686 if err := h.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: "act1"}}); err != nil {
687 t.Fatal(err)
688 }
689 if _, err := h.InvokeAction(context.Background(), "alpha", "nope", "sess-1", nil); err == nil {
690 t.Fatal("InvokeAction accepted an undeclared action")
691 }
692 if _, err := h.InvokeAction(context.Background(), "ghost", "act1", "sess-1", nil); err == nil {
693 t.Fatal("InvokeAction accepted an unknown plugin")
694 }
695 if _, err := h.InvokeAction(context.Background(), "alpha", "act1", "sess-old", nil); err == nil {
696 t.Fatal("InvokeAction accepted a stale session")
697 }
698 if _, err := h.InvokeAction(context.Background(), "alpha", "Bad ID", "sess-1", nil); err == nil {
699 t.Fatal("InvokeAction accepted an invalid action id")
700 }
701 if len(fake.actionParams) != 0 {
702 t.Fatalf("rejected invocations reached the client: %+v", fake.actionParams)
703 }
704 }
705
706 func TestSubmitRoutesFormValues(t *testing.T) {
707 fake := &fakeActionClient{submitResult: protocol.UISubmitResult{Accepted: true}}
708 h := New(Options{
709 SessionID: "sess-1", Generation: 7,
710 Resolve: func(string) ActionClient { return fake },
711 })
712 h.HandlerFor("alpha")
713 result, err := h.Submit(context.Background(), "alpha", "f1", "sess-1", map[string]any{"name": "x"})
714 if err != nil || !result.Accepted {
715 t.Fatalf("Submit = %+v, %v", result, err)
716 }
717 if len(fake.submitParams) != 1 {
718 t.Fatalf("client submit calls = %+v", fake.submitParams)
719 }
720 call := fake.submitParams[0]
721 if call.SurfaceID != "f1" || call.SessionID != "sess-1" || call.Generation != 7 || call.Values["name"] != "x" {
722 t.Fatalf("submit params = %+v", call)
723 }
724 }
725
726 func TestHubConcurrentUse(t *testing.T) {
727 rec := &eventRecorder{}
728 fake := &fakeActionClient{actionResult: protocol.UIActionResult{Accepted: true}, submitResult: protocol.UISubmitResult{Accepted: true}}
729 h := New(Options{
730 SessionID: "sess-1", Generation: 7, Emit: rec.emit,
731 Resolve: func(string) ActionClient { return fake },
732 Request: AskRequestFunc(func(context.Context, []event.AskQuestion) ([]event.AskAnswer, error) {
733 return []event.AskAnswer{{QuestionID: "value", Selected: []string{"Yes"}}}, nil
734 }),
735 })
736 if err := h.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: "act1"}}); err != nil {
737 t.Fatal(err)
738 }
739 var wg sync.WaitGroup
740 for i := range 8 {
741 wg.Add(1)
742 go func(i int) {
743 defer wg.Done()
744 handler := h.HandlerFor("alpha")
745 _, _ = handler.Publish(context.Background(), protocol.UIPublishParams{
746 SurfaceID: "s", SessionID: "sess-1", Generation: 7, Kind: protocol.UISurfaceStatus,
747 Payload: mustRaw(t, protocol.UIStatusPayload{Label: "x"}),
748 })
749 _, _ = handler.Request(context.Background(), protocol.UIRequestParams{
750 SurfaceID: "r", SessionID: "sess-1", Generation: 7, Kind: protocol.UIRequestConfirm,
751 Payload: mustRaw(t, protocol.UIFormPayload{Message: "m"}),
752 })
753 _, _ = h.InvokeAction(context.Background(), "alpha", "act1", "sess-1", nil)
754 _, _ = h.Submit(context.Background(), "alpha", "f", "sess-1", nil)
755 _ = h.Actions()
756 h.BindGeneration("sess-1", 7)
757 h.ClientCrashed("other")
758 h.SetResolver(func(string) ActionClient { return fake })
759 }(i)
760 }
761 wg.Wait()
762 }
763
763 lines GO