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