返回 DeepSeek-Reasonix
host_shell_test.go
根目录 / desktop / host_shell_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/desktop/internal/hostrpc"
12 "reasonix/internal/extension/rpcwire"
13 )
14
15 // fakeShell answers host/* requests from the Go side and records them.
16 type fakeShell struct {
17 conn *rpcwire.Conn
18 mu sync.Mutex
19 calls []string
20 replies map[string]any
21 }
22
23 func (f *fakeShell) methods() []string {
24 f.mu.Lock()
25 defer f.mu.Unlock()
26 return append([]string(nil), f.calls...)
27 }
28
29 func (f *fakeShell) handle(method string) {
30 f.conn.Handle(method, func(_ context.Context, _ json.RawMessage) (any, error) {
31 f.mu.Lock()
32 f.calls = append(f.calls, method)
33 reply := f.replies[method]
34 f.mu.Unlock()
35 if reply == nil {
36 return map[string]any{}, nil
37 }
38 return reply, nil
39 })
40 }
41
42 func newHostShellBridgeForTest(t *testing.T, replies map[string]any) (*App, *hostShellBridge, *fakeShell) {
43 t.Helper()
44 isolateDesktopUserDirs(t)
45 a := NewApp()
46 registry, err := newDesktopRegistry(a)
47 if err != nil {
48 t.Fatal(err)
49 }
50 stdinR, stdinW := io.Pipe()
51 stdoutR, stdoutW := io.Pipe()
52 serviceConn := rpcwire.NewConn(stdinR, stdoutW, rpcwire.Options{StrictJSONRPC: true, Name: "desktop-host"})
53 shellConn := rpcwire.NewConn(stdoutR, stdinW, rpcwire.Options{StrictJSONRPC: true, Name: "shell"})
54 shell := &fakeShell{conn: shellConn, replies: replies}
55 for _, method := range []string{
56 "host/tray.ensure", "host/tray.destroy", "host/window.show", "host/window.maximise", "host/window.unminimise",
57 "host/remoteWindow.open", "host/remoteWindow.close", "host/app.relaunch", "host/app.quit",
58 } {
59 shell.handle(method)
60 }
61 bridge := &hostShellBridge{app: a}
62 server := hostrpc.NewServer(serviceConn, hostrpc.ServerConfig{
63 Registry: registry, Contract: hostrpc.Build(registry, hostEventNames), Generation: "g-test",
64 Hooks: hostrpc.Hooks{HostEvent: bridge.handleHostEvent, BeforeClose: func(ctx context.Context, reason string) bool { return bridge.beforeClose(ctx, reason) }},
65 })
66 bridge.server = server
67 a.hostShell = bridge
68 a.setNativeHost(rpcNativeHost{server: server})
69 ctx, cancel := context.WithCancel(context.Background())
70 go func() { _ = server.Serve(ctx) }()
71 go func() { _ = shellConn.Serve(ctx) }()
72 t.Cleanup(func() {
73 cancel()
74 stdinW.Close()
75 stdoutW.Close()
76 })
77 return a, bridge, shell
78 }
79
80 func waitFor(t *testing.T, what string, cond func() bool) {
81 t.Helper()
82 deadline := time.Now().Add(5 * time.Second)
83 for time.Now().Before(deadline) {
84 if cond() {
85 return
86 }
87 time.Sleep(10 * time.Millisecond)
88 }
89 t.Fatalf("timed out waiting for %s", what)
90 }
91
92 func TestHostShellTrayFollowsTheShellAnswer(t *testing.T) {
93 a, _, shell := newHostShellBridgeForTest(t, map[string]any{"host/tray.ensure": map[string]any{"ready": true}})
94 if !a.startTray() {
95 t.Fatal("tray should start when the shell reports ready")
96 }
97 if !a.isTrayReady() {
98 t.Fatal("tray readiness must follow the shell reply")
99 }
100 if !a.startTray() {
101 t.Fatal("a second start reuses the existing tray")
102 }
103 a.updateTrayLocale("zh")
104 a.stopTray()
105 if a.isTrayReady() {
106 t.Fatal("tray must be unavailable after destroy")
107 }
108 got := shell.methods()
109 want := []string{"host/tray.ensure", "host/tray.ensure", "host/tray.destroy"}
110 if len(got) != len(want) {
111 t.Fatalf("shell calls %v, want %v", got, want)
112 }
113 for i := range want {
114 if got[i] != want[i] {
115 t.Fatalf("shell calls %v, want %v", got, want)
116 }
117 }
118 }
119
120 func TestHostShellTrayUnavailableWhenTheShellHasNone(t *testing.T) {
121 a, _, _ := newHostShellBridgeForTest(t, map[string]any{"host/tray.ensure": map[string]any{"ready": false, "reason": "platform_no_tray"}})
122 if a.startTray() {
123 t.Fatal("tray must not start when the shell has no tray")
124 }
125 a.mu.RLock()
126 state, reason, tray := a.desktopShell.trayState, a.desktopShell.trayReason, a.tray
127 a.mu.RUnlock()
128 if state != "unavailable" || reason != "platform_no_tray" || tray != nil {
129 t.Fatalf("tray state %q/%q tray=%v", state, reason, tray)
130 }
131 }
132
133 func TestHostShellRemoteWindowsTrackShellClosure(t *testing.T) {
134 a, bridge, shell := newHostShellBridgeForTest(t, nil)
135 launch := remoteWindowLaunch{URL: "http://127.0.0.1:1/", Title: "box", HostKey: remoteWindowHostKey("box")}
136 if err := a.openRemoteWindowForHost("box", "/srv", launch.URL); err != nil {
137 t.Fatal(err)
138 }
139 if !a.hasRemoteWindow("box") {
140 t.Fatal("window must be tracked after open")
141 }
142 payload, _ := json.Marshal(map[string]string{"hostKey": launch.HostKey})
143 if err := bridge.handleHostEvent(context.Background(), "remoteWindow.closed", payload); err != nil {
144 t.Fatal(err)
145 }
146 if a.hasRemoteWindow("box") {
147 t.Fatal("a window the user closed must be forgotten")
148 }
149 a.closeRemoteWindowForHost("box")
150 if calls := shell.methods(); len(calls) != 1 || calls[0] != "host/remoteWindow.open" {
151 t.Fatalf("closing a forgotten window must not reach the shell: %v", calls)
152 }
153 if err := a.openRemoteWindowForHost("box", "/srv", launch.URL); err != nil {
154 t.Fatal(err)
155 }
156 a.closeAllRemoteWindows()
157 if a.hasRemoteWindow("box") {
158 t.Fatal("closeAll must drop every window")
159 }
160 waitFor(t, "close request", func() bool {
161 calls := shell.methods()
162 return len(calls) == 3 && calls[2] == "host/remoteWindow.close"
163 })
164 }
165
166 func TestHostShellQuitReasonBypassesBackgroundClose(t *testing.T) {
167 _, bridge, _ := newHostShellBridgeForTest(t, nil)
168 if consumeSystemQuitRequested() {
169 t.Fatal("no system quit should be pending before the test")
170 }
171 if prevent := bridge.beforeClose(context.Background(), "quit"); prevent {
172 t.Fatal("a quit must never be prevented by background close")
173 }
174 if consumeSystemQuitRequested() {
175 t.Fatal("beforeClose must consume the quit marker it set")
176 }
177 }
178
179 func TestHostShellEventsReachTheAppEntryPoints(t *testing.T) {
180 a, bridge, shell := newHostShellBridgeForTest(t, nil)
181 a.ctx = context.Background()
182 for _, name := range []string{"secondInstance", "tray.open", "menu.showWindow", "unknown.event"} {
183 if err := bridge.handleHostEvent(context.Background(), name, json.RawMessage(`{}`)); err != nil {
184 t.Fatalf("%s: %v", name, err)
185 }
186 }
187 waitFor(t, "window show requests", func() bool {
188 shown := 0
189 for _, m := range shell.methods() {
190 if m == "host/window.show" {
191 shown++
192 }
193 }
194 return shown >= 3
195 })
196 }
197
197 lines GO