返回 DeepSeek-Reasonix
server_test.go
根目录 / desktop / internal / hostrpc / server_test.go
1 package hostrpc
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/extension/rpcwire"
13 )
14
15 type harness struct {
16 t *testing.T
17 server *Server
18 shell *rpcwire.Conn
19 events chan Event
20 serveErr chan error
21 stdinW *io.PipeWriter
22 contract Contract
23 home string
24 }
25
26 func newHarness(t *testing.T, target any, hooks Hooks) *harness {
27 t.Helper()
28 registry := mustRegistry(t, target, nil)
29 contract := Build(registry, []string{"agent:event", "runtime:rebuilt"})
30 home := t.TempDir()
31 stdinR, stdinW := io.Pipe()
32 stdoutR, stdoutW := io.Pipe()
33 serviceConn := rpcwire.NewConn(stdinR, stdoutW, rpcwire.Options{StrictJSONRPC: true, Name: "desktop-host", MaxConcurrentHandlers: 512})
34 shell := rpcwire.NewConn(stdoutR, stdinW, rpcwire.Options{StrictJSONRPC: true, Name: "shell", MaxQueuedNotifications: 64})
35 events := make(chan Event, 64)
36 shell.HandleNotify("desktop/event", func(_ context.Context, params json.RawMessage) {
37 var e Event
38 if err := json.Unmarshal(params, &e); err != nil {
39 t.Errorf("decode event: %v", err)
40 return
41 }
42 events <- e
43 })
44 server := NewServer(serviceConn, ServerConfig{
45 Registry: registry,
46 Contract: contract,
47 Hooks: hooks,
48 Identity: Identity{Version: "v1.0.0", Channel: "stable", Commit: "abc123", Home: home},
49 Generation: "g-test",
50 })
51 serveErr := make(chan error, 1)
52 ctx := t.Context()
53 go func() { serveErr <- server.Serve(ctx) }()
54 go func() { _ = shell.Serve(ctx) }()
55 t.Cleanup(func() {
56 stdinW.Close()
57 stdoutW.Close()
58 stdinR.Close()
59 stdoutR.Close()
60 })
61 return &harness{t: t, server: server, shell: shell, events: events, serveErr: serveErr, stdinW: stdinW, contract: contract, home: home}
62 }
63
64 func (h *harness) hello() HelloParams {
65 return HelloParams{
66 ProtocolVersion: ProtocolVersion,
67 ContractDigest: h.contract.Digest(),
68 Build: BuildInfo{Version: "v1.0.0", Channel: "stable", Commit: "abc123"},
69 Host: HostInfo{Name: "electron", Platform: "darwin"},
70 Instance: HelloInstance{Home: h.home},
71 }
72 }
73
74 func (h *harness) call(method string, params any, result any) error {
75 h.t.Helper()
76 raw, err := h.shell.Request(h.t.Context(), method, params)
77 if err != nil {
78 return err
79 }
80 if result != nil {
81 if err := json.Unmarshal(raw, result); err != nil {
82 h.t.Fatalf("%s: decode result %s: %v", method, raw, err)
83 }
84 }
85 return nil
86 }
87
88 func (h *harness) invoke(method string, args ...any) (json.RawMessage, error) {
89 h.t.Helper()
90 if args == nil {
91 args = []any{}
92 }
93 return h.shell.Request(h.t.Context(), "desktop/invoke", map[string]any{"method": method, "args": args})
94 }
95
96 func (h *harness) mustHello() HelloResult {
97 h.t.Helper()
98 var result HelloResult
99 if err := h.call("desktop/hello", h.hello(), &result); err != nil {
100 h.t.Fatalf("hello: %v", err)
101 }
102 return result
103 }
104
105 func assertCode(t *testing.T, err error, code int, name string) map[string]any {
106 t.Helper()
107 var re *rpcwire.ResponseError
108 if !errors.As(err, &re) {
109 t.Fatalf("error = %v, want JSON-RPC error %d", err, code)
110 }
111 if re.Code != code {
112 t.Fatalf("code = %d (%s), want %d", re.Code, re.Message, code)
113 }
114 data := map[string]any{}
115 if len(re.Data) > 0 {
116 if err := json.Unmarshal(re.Data, &data); err != nil {
117 t.Fatalf("decode error data %s: %v", re.Data, err)
118 }
119 }
120 if name != "" && data["name"] != name {
121 t.Fatalf("error data name = %v, want %s", data["name"], name)
122 }
123 return data
124 }
125
126 func TestServerRejectsEverythingBeforeHello(t *testing.T) {
127 h := newHarness(t, &fixtureTarget{}, Hooks{})
128 _, err := h.invoke("Platform")
129 assertCode(t, err, CodeNotReady, "not_ready")
130 assertCode(t, h.call("desktop/start", struct{}{}, nil), CodeNotReady, "not_ready")
131 assertCode(t, h.call("desktop/shutdown", struct{}{}, nil), CodeNotReady, "not_ready")
132 }
133
134 func TestServerServeReturnsWhenRuntimeContextIsCancelled(t *testing.T) {
135 stdinR, stdinW := io.Pipe()
136 stdoutR, stdoutW := io.Pipe()
137 t.Cleanup(func() {
138 _ = stdinW.Close()
139 _ = stdinR.Close()
140 _ = stdoutW.Close()
141 _ = stdoutR.Close()
142 })
143 conn := rpcwire.NewConn(stdinR, stdoutW, rpcwire.Options{StrictJSONRPC: true, Name: "cancel-test"})
144 server := NewServer(conn, ServerConfig{
145 Registry: mustRegistry(t, &fixtureTarget{}, nil),
146 Identity: Identity{Version: "dev", Home: t.TempDir()},
147 })
148 ctx, cancel := context.WithCancel(context.Background())
149 result := make(chan error, 1)
150 go func() { result <- server.Serve(ctx) }()
151 cancel()
152 select {
153 case err := <-result:
154 if !errors.Is(err, context.Canceled) {
155 t.Fatalf("Serve cancellation = %v", err)
156 }
157 case <-time.After(time.Second):
158 t.Fatal("Serve stayed blocked on stdin after context cancellation")
159 }
160 }
161
162 func TestServerHelloMismatchCodes(t *testing.T) {
163 h := newHarness(t, &fixtureTarget{}, Hooks{})
164 protocol := h.hello()
165 protocol.ProtocolVersion = ProtocolVersion + 1
166 assertCode(t, h.call("desktop/hello", protocol, nil), CodeProtocolMismatch, "protocol_mismatch")
167
168 contract := h.hello()
169 contract.ContractDigest = "sha256:0000"
170 data := assertCode(t, h.call("desktop/hello", contract, nil), CodeContractMismatch, "contract_mismatch")
171 if data["expected"] != h.contract.Digest() {
172 t.Fatalf("contract mismatch data = %v", data)
173 }
174
175 build := h.hello()
176 build.Build.Version = "v2.0.0"
177 assertCode(t, h.call("desktop/hello", build, nil), CodeBuildMismatch, "build_mismatch")
178
179 instance := h.hello()
180 instance.Instance.Home = t.TempDir()
181 assertCode(t, h.call("desktop/hello", instance, nil), CodeInstanceMismatch, "instance_mismatch")
182
183 _, err := h.invoke("Platform")
184 assertCode(t, err, CodeNotReady, "not_ready")
185
186 devBuild := h.hello()
187 devBuild.Build.Version = "v2.0.0"
188 devBuild.Instance.Dev = true
189 if err := h.call("desktop/hello", devBuild, nil); err != nil {
190 t.Fatalf("dev shell must skip the build check: %v", err)
191 }
192 assertCode(t, h.call("desktop/hello", h.hello(), nil), rpcwire.ErrInvalidRequest, "")
193 }
194
195 func TestServerHelloResultAndInvoke(t *testing.T) {
196 hooks := Hooks{Hello: func(p HelloParams) (HelloResult, error) {
197 if p.Host.Name != "electron" {
198 t.Errorf("hook saw host %+v", p.Host)
199 }
200 return HelloResult{
201 Resources: Resources{Origin: "http://127.0.0.1:1", Token: "tok"},
202 Window: &WindowGeometry{Width: 1240, Height: 720, MinWidth: 760, MinHeight: 480, ZoomFactor: 1},
203 }, nil
204 }}
205 h := newHarness(t, &fixtureTarget{}, hooks)
206 result := h.mustHello()
207 if result.ProtocolVersion != ProtocolVersion || result.ContractDigest != h.contract.Digest() || result.RuntimeGeneration != "g-test" {
208 t.Fatalf("hello result = %+v", result)
209 }
210 if result.Service.Version != "v1.0.0" || result.Service.Channel != "stable" || result.Service.Commit != "abc123" || result.Service.PID <= 0 {
211 t.Fatalf("service info = %+v", result.Service)
212 }
213 if result.Resources.Token != "tok" || result.Window == nil || result.Window.Width != 1240 {
214 t.Fatalf("hook fields lost: %+v", result)
215 }
216
217 platform, err := h.invoke("Platform")
218 if err != nil || string(platform) != `"test-os"` {
219 t.Fatalf("Platform = %s, %v", platform, err)
220 }
221 void, err := h.invoke("Void")
222 if err != nil || string(void) != "null" {
223 t.Fatalf("Void = %s, %v", void, err)
224 }
225 ping, err := h.invoke("Ping", "alpha", 2)
226 if err != nil || string(ping) != `{"id":"alpha","next":null}` {
227 t.Fatalf("Ping = %s, %v", ping, err)
228 }
229 _, err = h.invoke("Fail")
230 data := assertCode(t, err, CodeBusiness, "")
231 if data["method"] != "Fail" || err.Error() != "boom" {
232 t.Fatalf("business error = %v data %v", err, data)
233 }
234 _, err = h.invoke("Nope")
235 data = assertCode(t, err, rpcwire.ErrMethodNotFound, "")
236 if data["method"] != "Nope" {
237 t.Fatalf("unknown method data = %v", data)
238 }
239 _, err = h.invoke("Ping", 1)
240 assertCode(t, err, rpcwire.ErrInvalidParams, "")
241 _, err = h.invoke("Explode")
242 assertCode(t, err, rpcwire.ErrInternal, "")
243 }
244
245 func TestServerEventsKeepCallOrderAndSequence(t *testing.T) {
246 h := newHarness(t, &fixtureTarget{}, Hooks{})
247 h.mustHello()
248 h.server.Emit("agent:event", map[string]any{"kind": "text"})
249 h.server.Emit("runtime:rebuilt", "tab-1", 3)
250 h.server.Emit("agent:ready")
251 want := []struct {
252 name string
253 args string
254 }{
255 {"agent:event", `[{"kind":"text"}]`},
256 {"runtime:rebuilt", `["tab-1",3]`},
257 {"agent:ready", `[]`},
258 }
259 for i, w := range want {
260 e := <-h.events
261 args, _ := json.Marshal(e.Args)
262 if e.Seq != int64(i+1) || e.Generation != "g-test" || e.Name != w.name || string(args) != w.args {
263 t.Fatalf("event %d = %+v (args %s), want seq %d %s %s", i, e, args, i+1, w.name, w.args)
264 }
265 }
266 }
267
268 func TestServerRequestRoundTripsHostCalls(t *testing.T) {
269 h := newHarness(t, &fixtureTarget{}, Hooks{})
270 h.shell.Handle("host/window.isMaximised", func(_ context.Context, _ json.RawMessage) (any, error) {
271 return map[string]bool{"value": true}, nil
272 })
273 h.shell.Handle("host/dialog.openDirectory", func(_ context.Context, params json.RawMessage) (any, error) {
274 return nil, &rpcwire.RPCError{Code: -1, Message: "cancelled: " + string(params)}
275 })
276 var out struct {
277 Value bool `json:"value"`
278 }
279 if err := h.server.Request(t.Context(), "host/window.isMaximised", struct{}{}, &out); err != nil || !out.Value {
280 t.Fatalf("isMaximised = %+v, %v", out, err)
281 }
282 if err := h.server.Request(t.Context(), "host/window.show", map[string]string{"reason": "domReady"}, nil); err == nil {
283 t.Fatal("unhandled host method must surface an error")
284 }
285 err := h.server.Request(t.Context(), "host/dialog.openDirectory", map[string]string{"title": "Pick"}, nil)
286 var re *rpcwire.ResponseError
287 if !errors.As(err, &re) || re.Code != -1 || re.Message != `cancelled: {"title":"Pick"}` {
288 t.Fatalf("host error = %v", err)
289 }
290 }
291
292 func TestServerRoutesLifecycleRequestsToHooks(t *testing.T) {
293 var log []string
294 hooks := Hooks{
295 Start: func(context.Context) error { log = append(log, "start"); return nil },
296 DOMReady: func(context.Context) error { log = append(log, "domReady"); return nil },
297 RendererAttached: func(_ context.Context, gen int) error {
298 log = append(log, "renderer:"+string(rune('0'+gen)))
299 return nil
300 },
301 BeforeClose: func(_ context.Context, reason string) bool {
302 log = append(log, "beforeClose:"+reason)
303 return reason == "window"
304 },
305 Shutdown: func(_ context.Context, params ShutdownParams) (ShutdownResult, error) {
306 log = append(log, "shutdown")
307 return ShutdownResult{RequestID: params.RequestID, Reason: params.Reason, Phase: "completed", Outcome: "success", Completed: true}, nil
308 },
309 ShutdownStatus: func(_ context.Context, params ShutdownStatusParams) (ShutdownResult, error) {
310 return ShutdownResult{RequestID: params.RequestID, Phase: "completed", Outcome: "success", Completed: true}, nil
311 },
312 HostEvent: func(_ context.Context, name string, payload json.RawMessage) error {
313 log = append(log, "host:"+name+":"+string(payload))
314 return errors.New("unhandled host event")
315 },
316 }
317 h := newHarness(t, &fixtureTarget{}, hooks)
318 h.mustHello()
319 var empty map[string]any
320 for _, method := range []string{"desktop/start", "desktop/domReady"} {
321 if err := h.call(method, struct{}{}, &empty); err != nil || len(empty) != 0 {
322 t.Fatalf("%s = %v, %v", method, empty, err)
323 }
324 }
325 if err := h.call("desktop/rendererAttached", map[string]int{"rendererGeneration": 7}, nil); err != nil {
326 t.Fatal(err)
327 }
328 var close struct {
329 Prevent bool `json:"prevent"`
330 }
331 if err := h.call("desktop/beforeClose", map[string]string{"reason": "window"}, &close); err != nil || !close.Prevent {
332 t.Fatalf("beforeClose window = %+v, %v", close, err)
333 }
334 if err := h.call("desktop/beforeClose", map[string]string{"reason": "quit"}, &close); err != nil || close.Prevent {
335 t.Fatalf("beforeClose quit = %+v, %v", close, err)
336 }
337 err := h.call("desktop/hostEvent", map[string]any{"name": "tray.open", "payload": []string{"x"}}, nil)
338 assertCode(t, err, rpcwire.ErrInternal, "")
339 if err := h.call("desktop/shutdown", ShutdownParams{RequestID: "request-1", Reason: "user_quit"}, &empty); err != nil {
340 t.Fatal(err)
341 }
342 if err := <-h.serveErr; err != nil {
343 t.Fatalf("Serve after shutdown = %v", err)
344 }
345 want := "start,domReady,renderer:7,beforeClose:window,beforeClose:quit,host:tray.open:[\"x\"],shutdown"
346 if got := strings.Join(log, ","); got != want {
347 t.Fatalf("hook log = %s, want %s", got, want)
348 }
349 }
350
351 func TestServerStopsWhenStdinCloses(t *testing.T) {
352 h := newHarness(t, &fixtureTarget{}, Hooks{})
353 h.mustHello()
354 h.stdinW.Close()
355 if err := <-h.serveErr; err != nil {
356 t.Fatalf("Serve after EOF = %v", err)
357 }
358 }
359
359 lines GO