返回 DeepSeek-Reasonix
extension_ui_test.go
根目录 / internal / boot / extension_ui_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/config"
12 "reasonix/internal/event"
13 "reasonix/internal/extension/protocol"
14 )
15
16 // uiSinkRecorder collects controller events for the stage-8a boot tests.
17 type uiSinkRecorder struct {
18 mu sync.Mutex
19 events []event.Event
20 }
21
22 func (r *uiSinkRecorder) emit(ev event.Event) {
23 r.mu.Lock()
24 defer r.mu.Unlock()
25 r.events = append(r.events, ev)
26 }
27
28 func (r *uiSinkRecorder) hasExtensionStatus() (event.ExtensionSurfacePayload, bool) {
29 r.mu.Lock()
30 defer r.mu.Unlock()
31 for _, ev := range r.events {
32 if ev.Kind == event.ExtensionStatus && ev.Extension != nil {
33 return *ev.Extension, true
34 }
35 }
36 return event.ExtensionSurfacePayload{}, false
37 }
38
39 // bootWithFakeUIPlugin mirrors bootWithFakePlugin but lets the test inject
40 // the sink the build's controller emits to.
41 func bootWithFakeUIPlugin(t *testing.T, name string, runtime map[string]any, sink event.Sink) *BuildResult {
42 t.Helper()
43 isolateConfigHome(t)
44 dir := robustTempDir(t)
45 t.Chdir(dir)
46 writeRuntimeFixture(t, dir)
47 installBootFakePlugin(t, config.ReasonixHomeDir(), name, runtime)
48 res, err := BuildRuntime(context.Background(), Options{Sink: sink})
49 if err != nil {
50 t.Fatalf("BuildRuntime: %v", err)
51 }
52 t.Cleanup(res.Controller.Close)
53 return res
54 }
55
56 // TestBootExtensionUIHubWired proves the stage-8a end-to-end path: the hub is
57 // installed as the sidecar's host/ui/* handler, a credential-bearing status
58 // publish reaches the frontend sink redacted, handshake-declared actions land
59 // in the registry, and the controller's Capabilities port enumerates and
60 // invokes them.
61 func TestBootExtensionUIHubWired(t *testing.T) {
62 rec := &uiSinkRecorder{}
63 res := bootWithFakeUIPlugin(t, "uibootplugin", map[string]any{
64 "capabilities": []string{"ui"},
65 "env": map[string]string{
66 bootFakeEnvInitResult: `{"protocolVersion":"1","name":"uibootplugin","version":"1.0.0","stateSchemaVersion":0,` +
67 `"uiActions":[{"actionId":"act1","label":"Act one"}]}`,
68 bootFakeEnvUIPublish: "1",
69 },
70 }, event.FuncSink(rec.emit))
71
72 if res.ExtensionUI == nil {
73 t.Fatal("BuildRuntime returned no extension UI hub")
74 }
75
76 // The fake sidecar publishes one status surface right after the
77 // handshake; the hub must gate it (bound session + generation), redact
78 // it, and emit it to the controller sink.
79 deadline := time.Now().Add(15 * time.Second)
80 for {
81 if payload, ok := rec.hasExtensionStatus(); ok {
82 if payload.PluginID != "uibootplugin" || payload.SurfaceID != "boot-status" {
83 t.Fatalf("extension status payload = %+v", payload)
84 }
85 if payload.Status == nil || !strings.Contains(payload.Status.Label, "boot fake ready") {
86 t.Fatalf("status view = %+v", payload.Status)
87 }
88 if strings.Contains(payload.Status.Label, "sk-abcdef") {
89 t.Fatalf("status label not redacted: %q", payload.Status.Label)
90 }
91 break
92 }
93 if time.Now().After(deadline) {
94 t.Fatal("no ExtensionStatus event reached the controller sink")
95 }
96 time.Sleep(10 * time.Millisecond)
97 }
98
99 // The handshake-declared action is registered under its slash name.
100 actions := res.ExtensionUI.Actions()
101 if len(actions) != 1 || actions[0].Slash != "/uibootplugin:act1" || actions[0].Label != "Act one" {
102 t.Fatalf("hub actions = %+v", actions)
103 }
104 portActions := res.Controller.ExtensionActions()
105 if len(portActions) != 1 || portActions[0].Slash != "/uibootplugin:act1" {
106 t.Fatalf("controller ExtensionActions = %+v", portActions)
107 }
108
109 // Invocation routes through the hub to the fake sidecar over the wire.
110 message, err := res.Controller.InvokeExtensionAction(context.Background(), "/uibootplugin:act1", nil)
111 if err != nil {
112 t.Fatalf("InvokeExtensionAction: %v", err)
113 }
114 if message != "boot fake action ran" {
115 t.Fatalf("action message = %q", message)
116 }
117 if err := res.Controller.SubmitExtensionForm(context.Background(), "uibootplugin", "boot-status", map[string]any{"k": "v"}); err != nil {
118 t.Fatalf("SubmitExtensionForm: %v", err)
119 }
120 }
121
122 // TestBootExtensionUIHubNilWithoutPlugins pins the zero-change contract: with
123 // no v1 runtime packages there is no hub, no controller install, and the
124 // Capabilities port degrades to empty/error.
125 func TestBootExtensionUIHubNilWithoutPlugins(t *testing.T) {
126 isolateConfigHome(t)
127 dir := robustTempDir(t)
128 t.Chdir(dir)
129 writeRuntimeFixture(t, dir)
130 res, err := BuildRuntime(context.Background(), Options{})
131 if err != nil {
132 t.Fatalf("BuildRuntime: %v", err)
133 }
134 t.Cleanup(res.Controller.Close)
135 if res.ExtensionUI != nil {
136 t.Fatal("build without extension packages returned a UI hub")
137 }
138 if got := res.Controller.ExtensionActions(); len(got) != 0 {
139 t.Fatalf("ExtensionActions = %+v, want empty", got)
140 }
141 if _, err := res.Controller.InvokeExtensionAction(context.Background(), "/alpha:act1", nil); err == nil {
142 t.Fatal("InvokeExtensionAction succeeded without a hub")
143 }
144 }
145
146 // TestRebuildRebindsExtensionUIHub proves the reload contract: a rebuild
147 // starts fresh sidecars on a new generation and hands the controller a hub
148 // bound to that generation, so stale publications from the old generation
149 // can never land.
150 func TestRebuildRebindsExtensionUIHub(t *testing.T) {
151 old := bootWithFakePlugin(t, "rebuilduiplugin", map[string]any{
152 "capabilities": []string{"ui"},
153 "env": map[string]string{
154 bootFakeEnvInitResult: `{"protocolVersion":"1","name":"rebuilduiplugin","version":"1.0.0","stateSchemaVersion":0,` +
155 `"uiActions":[{"actionId":"act1"}]}`,
156 },
157 })
158 if old.ExtensionUI == nil {
159 t.Fatal("old build has no UI hub")
160 }
161 oldGeneration := old.ExtensionUI.Generation()
162
163 newRes, err := Rebuild(context.Background(), old.Controller, Options{})
164 if err != nil {
165 t.Fatalf("Rebuild: %v", err)
166 }
167 if newRes.ExtensionUI == nil {
168 t.Fatal("rebuilt runtime has no UI hub")
169 }
170 if newRes.ExtensionUI.Generation() <= oldGeneration {
171 t.Fatalf("hub generation did not increase: old %d, new %d", oldGeneration, newRes.ExtensionUI.Generation())
172 }
173 if got := newRes.ExtensionUI.Actions(); len(got) != 1 || got[0].Slash != "/rebuilduiplugin:act1" {
174 t.Fatalf("rebuilt hub actions = %+v", got)
175 }
176 // The new hub is bound to the new generation: a late publication carrying
177 // the old generation is dropped (debug), never overwriting the new state.
178 raw, err := json.Marshal(map[string]any{"label": "late"})
179 if err != nil {
180 t.Fatalf("marshal: %v", err)
181 }
182 result, err := newRes.ExtensionUI.HandlerFor("rebuilduiplugin").Publish(context.Background(), protocol.UIPublishParams{
183 SurfaceID: "s1", SessionID: newRes.ExtensionUI.SessionID(), Generation: oldGeneration,
184 Kind: protocol.UISurfaceStatus, Payload: raw,
185 })
186 if err != nil {
187 t.Fatalf("old-generation publish errored (want a silent drop): %v", err)
188 }
189 if result.Accepted {
190 t.Fatal("old-generation publish accepted by the re-bound hub")
191 }
192 }
193
193 lines GO